Skip to content

feat(sdk): add the Platform API and managed-user mode - #6

Merged
Charlie-Qilin merged 12 commits into
mainfrom
feat/platform-managed-user
Oct 19, 2026
Merged

Charlie-Qilin merged 12 commits into
mainfrom
feat/platform-managed-user

Conversation

@a7vinx

@a7vinx a7vinx commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What and why

Pine's B2B Platform API lets a partner (a business that builds on Pine) hold a secret key (pine_sk_live_...) and create managed users identified by its own external_id. This PR makes the SDK the complete enterprise integration: create the managed user, then act as that user over REST and Socket.IO.

This revision follows the current backend contract. The backend now names the managed user by its Pine user ID, not by its external_id, when a request acts for that user.

Public API

  • AsyncPineAI(api_key=...) / PineAI(api_key=...) is a partner client. Keys must start with pine_sk_live_ or pine_sk_test_.
    • client.platform.managed_users.create(external_id, *, email, name, phone=None) is idempotent: 201 on create, 200 with the stored user unchanged afterwards.
    • client.platform.managed_users.get(external_id). The external_id goes into the path: it is validated first, then percent-encoded.
    • Both return a typed ManagedUser (id, external_id, email, name, phone, created_at) and raise PlatformError with a stable code and status_code, never the upstream body.
    • Platform calls never send Pine-Managed-User-Id.
  • AsyncPineAI(api_key=..., user_id=user.id) / PineAI(api_key=..., user_id=user.id) acts as that managed user. user.id is the id of the ManagedUser. The partner stores it in its own user table after create().
    • REST requests send Authorization: Bearer <key> and Pine-Managed-User-Id: <id>.
    • The Socket.IO handshake sends {"token": key, "managed_user_id": "<id>"}. The value is always a JSON string.
    • Envelopes carry the same ID as user_id. One value feeds the header, the handshake and the envelope.
    • connect() sends no REST request first. It no longer calls auth.me().
    • The managed-user header is bound to the configured key. It is never sent with another token, such as a per-request token=.
  • user_id in key mode must be a str in the backend's form: ASCII [1-9][0-9]{0,18} and at most 9223372036854775807. No sign, spaces or leading zeros. int and bool are refused with a message that shows the right form (user_id must be a string such as "123456789"). The check runs when the client is created. validate_user_id is public, so other clients can use the same check. validate_external_id stays for create()/get() only.
  • The modes:
    • api_key + user_id: act as that managed user.
    • api_key alone: Platform API only. A user-scoped /v2 call (for example sessions.list(), auth.me(), connect()) raises ValueError before any request. The message says to pass user_id.
    • api_key + access_token: ValueError.
    • user_id without api_key: the normal token mode, unchanged.
    • In key mode, connect(access_token=..., user_id=...) overrides: ValueError.
    • A key client keeps its key. set_token() and auth.verify_code() on it raise ValueError; verify_code() checks before the code is sent. Before, verify_code() silently replaced the key with the user JWT, so later calls acted as the email user, not the managed user.
    • platform.* on a client without api_key raises ValueError before any request, not a 401 from the backend.
    • A Platform key passed as access_token raises ValueError that says to pass it as api_key.
    • Key mode comes only from the explicit api_key, never from the token's text. The clients pass the checked api_key to the transport (HttpClient(api_key=...) / SyncHttpClient(api_key=...)). A transport built with token= is always a token client, even if the token starts with pine_sk_. Passing both token and api_key to the transport raises ValueError.
  • The backend refuses a bad handshake by accepting the connection and then closing it, with no connect_error and no code. connect() still waits for ready until ready_timeout (15 s by default) and then raises ConnectionError. In key mode the message says to check api_key and user_id. It does not fail at the first close: python-socketio reconnects after a short network break, and a fail-fast rule would turn that break into a false credentials error.
  • New sessions.history(session_id, *, limit=30, before_message_id=None) (async and sync) reads one page of stored messages over REST (GET /v2/sessions/{id}/messages) and returns a typed SessionHistoryPage (SessionHistoryMessage: message_id, type, version, data as a JSON string, created_at). Before this, the agent's replies were readable only over Socket.IO, so the sync PineAI had no way to read them. The backend accepts the key plus Pine-Managed-User-Id on this route.
  • Identity headers (Authorization, Pine-Managed-User-Id) are applied after an injected client's defaults and after caller headers=, so neither can override them.
  • Public names: API_KEY_PREFIXES, validate_external_id, validate_user_id, MANAGED_USER_HEADER ("Pine-Managed-User-Id"), PlatformAPI, ManagedUsersAPI (and their sync variants), ManagedUser and PlatformError.
  • Error messages from the SDK never contain the key, the external_id or the user ID.
  • Removed against the earlier revision of this PR (never released): the client_name parameter and CLIENT_HEADER (the backend only logged the value mcp; Pine's MCP service drops it too), the managed_user parameter, the Pine-Managed-User header, the managed_user handshake field, and the auth.me() call in connect(). There is no alias.

The README section "Platform API for partners" (renamed from "Integrating as an enterprise (Platform API)") now shows: create once, store user.id, then AsyncPineAI(api_key=..., user_id=user.id). It lists the 400 and 404 cases and the refused-handshake behavior. It says which methods raise SessionError and which raise PineAIError itself (for example sessions.create and sessions.start_task), and names sessions.start_task correctly.

New docs for managed users, linked from the README. They are kept short on purpose, because the API and the SDK are still changing. Examples are not in this PR.

  • docs/http-api.md (raw HTTP API): how to get a key (support@19pine.ai); one partner's managed users share its credits and limit; create/get a managed user, with a caution that the email and phone must be the end user's own, verified values; the two headers and their errors; the session routes, including GET /v2/sessions/{id}/messages; how a task runs (it starts on its own, as for a normal Pine user) and what each session state means; and how to talk with the agent (Socket.IO recommended, HTTP polling possible).
  • docs/sdk-api.md (Python SDK): the same, as SDK methods.
  • docs/socketio.md (reference only, partial, can change): handshake, envelope, the main events, and how to handle a disconnect: read the history again, or catch up from a revision with session:messages_since when the join reply has max_message_revision.

Versioning

This PR sets the version to 0.5.0rc2 in pyproject.toml and src/pine_assistant/__init__.py, and the CHANGELOG entry is ## [0.5.0rc2] - 2026-10-19 (the date is updated at merge). No tag is created, so publish.yml does not run and nothing goes to PyPI. This is the same as 0.5.0rc1.

Backend contract

What the backend expects:

  • /api/v2 for a managed user: Authorization: Bearer pine_sk_... and Pine-Managed-User-Id: <Pine user id>. The header Pine-Managed-User: <external_id> is gone.
  • Socket.IO handshake auth: {"token": key, "managed_user_id": "<Pine user id>"}. The value must be a JSON string. The old field managed_user is refused. A refused handshake is a server disconnect after connect, with no connect_error and no code.
  • The ID is the id from POST /api/platform/v1/managed-users (201/200) or GET /api/platform/v1/managed-users/{external_id}. The backend accepts only a canonical decimal from 1 to 9223372036854775807. The SDK check matches this rule.
  • Errors on /v2 with a key:
    • key without the header, header without a key, a repeated header, or a bad ID → 400 invalid_request;
    • a bad, unknown, revoked or other-environment key → 401 unauthorized;
    • an ID that is not a managed user of this partner → 404 platform_managed_user_not_found;
    • a banned or archived managed user → 403 platform_managed_user_unavailable;
    • a suspended partner → 403 platform_access_suspended;
    • a route closed to partner keys → 403 platform_route_not_allowed (for example auth.mint_ticket());
    • also 402 platform_spend_limit_reached, 409 platform_session_limit_reached, 409 platform_session_state_conflict, 429 too_many_requests.
  • The earlier access switch and the code platform_managed_user_access_disabled were removed from the backend. The SDK and README no longer mention them.
  • Unchanged: the key prefixes, create/get with external_id, no managed-user header on Platform calls, and PlatformError.

Why the backend changed from external_id to the Pine user ID:

  • Every /v2 route and every Socket.IO envelope already names objects by Pine IDs, for example session IDs and the user ID in envelopes. A partner must hold the Pine user ID anyway.
  • One ID removes the external_id → user lookup on every request.
  • Stream, Sendbird and Knock name end users by an ID in the same way.

Dependencies

This SDK version needs a backend that accepts Pine-Managed-User-Id and the managed_user_id handshake field. Merge it only when that backend is live.

Tests

  • tests/test_platform.py runs against a fake backend on 127.0.0.1 (aiohttp + python-socketio). It covers:
    • REST identity: the key, Pine-Managed-User-Id with the ID, and no old Pine-Managed-User header;
    • the handshake {"token": key, "managed_user_id": "<id>"} with a string value, across a reconnect, and no /auth/me request from connect();
    • the envelope user_id equals the ID;
    • an unchanged user-token handshake;
    • a server that closes the handshake before ready: connect() raises ConnectionError after ready_timeout, with the key-mode hint or the token-mode hint;
    • a partner-only client (key, no user_id): sessions.list(), auth.me() and connect() raise ValueError that says to pass user_id, with no request and no handshake sent; the Platform call still works; the sync client does the same;
    • connect() overrides in key mode raise ValueError;
    • a key client refuses set_token() and auth.verify_code() (async and sync) with no request sent;
    • platform.* on a token client raises ValueError with no request sent (async and sync);
    • validate_user_id refuses "0", "01", "+1", " 1", "1 ", "1\n", "", "9223372036854775808", "١" (Arabic-Indic digit), "usr_1", 42, True, None, and accepts "1", "123456789" and the boundary "9223372036854775807";
    • invalid configurations on both clients (key + token, bad key prefixes, an external_id / leading zero / out-of-range / int / bool as user_id, a Platform key passed as access_token), and that managed_user= and client_name= are gone;
    • create/get typing, a fixed stored user on repeat create, and percent-encoding;
    • Platform errors 400/401/403/404/409/429/500, and user-scoped 400 invalid_request, 401, 402, 403 (platform_access_suspended, platform_managed_user_unavailable, platform_route_not_allowed), 404 platform_managed_user_not_found, 409 (both codes) and 429 on sessions.list();
    • sessions.history(): path, query, typed page, an empty next_message_id read as None, extra fields kept, input checks before any request, SessionError without the upstream body, and the managed-user headers;
    • key mode from api_key only: a transport built with token=<key> is a token client (Platform call refused, set_token() allowed), one built with api_key=<key> is a key client, and both together are refused;
    • key binding with a per-request token (another JWT or another pine_sk_ key gets no managed-user header), caller and injected headers that cannot override identity, and the public names.
  • No error message contains the key, the external_id or the user ID. The fake backend puts all three in its error bodies to check this.
  • Every run used an environment with only HOME/PATH and proxies pointed at a dead local port.
ruff check src tests                       -> All checks passed!
pytest tests --ignore=tests/integration    -> 220 passed

🤖 Generated with Claude Code

a7vinx and others added 10 commits October 7, 2026 10:35
A tenant client built with api_key can create and get managed users
through client.platform.managed_users, with typed ManagedUser values and
PlatformError failures that never carry upstream bodies. Adding
managed_user acts as that user: REST requests send the key and a
Pine-Managed-User header, the Socket.IO handshake sends token and
managed_user, and connect() resolves the Pine user ID through auth.me().
client_name adds a Pine-Client header to every REST request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
client_name now also goes to the Socket.IO handshake as a Pine-Client
header, where the backend reads it for attribution. The identity headers
become a method of a base shared by both HTTP transports instead of a
module function reading their private fields. The README states that the
backend records Pine-Client only for tenant-key requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The managed-user header is sent only with the API key it was configured
with, never after set_token() or with a per-request token. Identity
headers (Authorization, Pine-Managed-User, Pine-Client) are applied after
caller headers, as on main, so http.* callers cannot override them.
API keys must start with pine_sk_live_ or pine_sk_test_. API_KEY_PREFIXES,
validate_external_id, MANAGED_USER_HEADER and CLIENT_HEADER are exported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mint_ticket() always fails with 403 platform_route_not_allowed as
AuthError, and while the backend's access switch is off user-scoped
calls fail with 403 platform_managed_user_access_disabled as the
resource's usual error type. A test pins both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend now names the managed user by its Pine user ID, not by its
external_id. A partner client acts for a managed user with
AsyncPineAI(api_key=key, user_id=user.id). The same ID goes in the
Pine-Managed-User-Id header, the managed_user_id handshake field and the
envelope user_id.

- Remove the managed_user parameter and the Pine-Managed-User header.
- Check user_id as a canonical decimal string; export validate_user_id.
- connect() no longer calls auth.me().
- A key client without user_id can call only the Platform API.
- A key client keeps its key: set_token() and auth.verify_code() raise
  ValueError, checked before the code is sent.
- platform.* on a client without api_key raises ValueError.
- Add sessions.history() to read stored messages over REST.
- Set the version to 0.5.0rc2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewrite the README Platform API section for user_id, and add docs for
partners: the SDK methods (docs/sdk-api.md), the raw HTTP API
(docs/http-api.md) and a Socket.IO reference (docs/socketio.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reply can take a few seconds to appear in the REST history, so a reader
that sees a new state should read again. Add a short "Answer a form"
section: read the fields from the live event or from the history, then
pass one answer per field name to submit_form_response().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Charlie-Qilin
Charlie-Qilin marked this pull request as ready for review October 19, 2026 14:55
Charlie-Qilin and others added 2 commits October 19, 2026 23:20
The backend only logged Pine-Client: mcp and changed no behavior, so
remove client_name and CLIENT_HEADER. Pine's MCP service drops it too.

- A Platform key passed as access_token raises ValueError that says to
  pass it as api_key.
- SessionHistoryPage.normalize_cursor uses if/else.
- README: rename the section to "Platform API for partners" and drop
  end_task from the managed-user SessionError list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The transport treated any token that starts with pine_sk_ as a Platform
key, so a transport built with token=<key> sent Platform requests and
refused set_token(), although no api_key was given. The clients now pass
the checked api_key to HttpClient and SyncHttpClient, and the transport
uses only that value. A transport built with token= is always a token
client; passing both token and api_key raises ValueError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Charlie-Qilin
Charlie-Qilin merged commit 20c880c into main Oct 19, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants